Overview

HexDroid keeps the app itself tiny with a script interpreter, a UI renderer, and the crypto primitives, and letting behaviour arrive as user-loadable .hex scripts. A script can react to incoming messages, add slash-style commands, call out to the web, schedule work, and even paint a full interactive screen.

The language is small and mIRC-flavoured, but it is its own thing. If you have written an mIRC remote or an aliases file before, most of this will feel familiar; if not, the examples below are enough to start from scratch.

Sandboxed by design. Every script runs under a bounded budget (an instruction and time limit per dispatch) and can only reach the network through http.get/http.post/media.upload, which the app gates, and not at all while a network that uses a proxy is connected. There is no filesystem access: the one way a script sees a file is media.pick, where HexDroid asks first and you choose the file yourself. There is one generic view renderer, so a script describes a screen, it never ships native code.

Scripts are in Menu > Scripts. The bundled scripts shipped with HexDroid are disabled by default, you opt them in. You can also paste, import, edit, and revert scripts from there; see Installing & editing.

Anatomy of a script

A .hex file is a flat list of two kinds of block:

  • on <event> { … }, an event handler that runs when something happens.
  • alias <name> { … }, a reusable command you can call from anywhere (and that the user can run as /name).

Inside a block, statements are separated by a newline or a pipe |. Comments start with ; and run to the end of the line.

; greeter.hex, answer when someone mentions you
on TEXT {
  if ($contains($text, $me) == true) {
    msg $chan hi $nick, you mentioned me     ; newline or | separates statements
  }
}

alias wave { me waves at $1- }                ; usable as: /wave everyone

That is the whole shape of it: handlers react to events, aliases package up commands, and statements do the work. Everything else on this page is the vocabulary, the events you can hook, the variables you can read, the commands and functions you can call, and the view DSL for drawing screens.

Events

An on <event> block registers a handler. Event keys are case-insensitive.

EventFires when…
on LOADthe script is loaded or reloaded (e.g. on app start, or after you enable/edit it). Use it to set defaults.
on TEXTa message arrives in any buffer. The handler can read, rewrite, or suppress the line before it is shown, see the pipeline note below.
on ACTIONlike TEXT, but for /me actions.
on INPUTyou send a line, before it leaves. Same rewrite/halt pipeline, applied to your outgoing text.
on NUMERICa server line is about to be printed. $code holds the numeric and $whois is true when the line answers a WHOIS you ran. The same rewrite/halt pipeline applies, so this is how you filter server output such as WHOIS replies.
on SIGNAL:NAMEa custom signal NAME is raised, by the signal command, a timer, a view button, or another script.

Capabilities raise signals too: the encrypted transport delivers messages as on SIGNAL:age_msg and dealt secrets as on SIGNAL:age_deal (see Encrypted transport). The same on SIGNAL:… form handles them.

The TEXT pipeline

Inside on TEXT you can change what the user actually sees:

  • rewrite <new text>, replace the line's text (e.g. to strip or annotate it).
  • halt, stop processing and drop the line entirely.
on TEXT {
  if ($contains($text, "spoiler") == true) { halt }   ; hide spoilers
  rewrite $replace($text, ":wave:", "👋")       ; expand a shortcode
}

The same two statements work in on ACTION, on INPUT and on NUMERIC. In a NUMERIC handler, halt hides the line only, the client still acts on it internally, so dropping an end-of-MOTD or an error numeric changes nothing but the display:

on NUMERIC {
  if ($whois != true) { return }  ; leave the same codes alone outside a WHOIS
  if ($code == 344) { halt }     ; 344 RPL_WHOISCOUNTRY, don't want it
  if ($code == 350) { halt }     ; 350 RPL_WHOISISWEBIRC
}

An optional glob filter narrows TEXT, ACTION and NUMERIC: on TEXT:*help* only runs when the text matches. The filter always tests the text, so filter a numeric by testing $code in the body as above.

Variables & arguments

Global variables, %name

Variables prefixed with % hold strings, numbers, lists, or maps, and keep their value between events until the scripts are reloaded. They are shared by every loaded script and by every network: two scripts that both set %ep overwrite each other, and whichever on LOAD runs last wins. Give a script's settings a prefix of their own (%img_ep, %tr_ep).

CommandEffect
set %x <value>assign (omit the value to clear it)
set -l %x <value>assign a local that lives only for the current handler or alias call and hides a global of the same name. Use it for scratch values.
unset %xremove the variable
inc %x/dec %xincrement/decrement a numeric variable
push %list <value>append to a list
setat %coll <key> <value>set a key/index inside a map or list (nestable)

Built-in fields, $field

Inside a handler, these read-only fields describe the event:

FieldValue
$meyour current nick
$nickthe sender of the message/event
$chan/$buffer/$targetthe buffer the event belongs to (channel or query)
$networkthe network id
$textthe message text (in on TEXT)
$ismetrue if the message is your own, else false
$codethe three digit numeric (in on NUMERIC), empty for server text that carries none
$whoistrue when the line (in on NUMERIC) answers a WHOIS you ran

Positional arguments, $1, $2-

When you call an alias, or raise a signal with extra words, or attach args to a view button, those words bind to numbered arguments:

TokenMeaning
$1, $2, …the first, second, … argument
$2-every argument from the second onward, space-joined
$1-all arguments, space-joined

Expansion is recursive: %vars, $fields, and $func(…) calls can all be nested inside each other.

alias greet {
  set -l %who $1                    ; first argument, local to this call
  msg $chan Hello %who, and also $2-   ; mix args, vars, and rest
}

Commands

A statement is a command followed by arguments. User aliases are commands too, and a fixed set of IRC commands can be run as well (see below).

CommandDescriptionExample
echoprint a local line to a buffer (not sent to the server)echo $chan loaded ok
msgsend a message to a targetmsg #room hello all
rawsend a raw IRC line to the serverraw WHO #room
rewrite(in on TEXT, ACTION, INPUT or NUMERIC) replace the linerewrite $upper($text)
set/unset/inc/dec/push/setatvariable operations (see above)setat %score $nick 0
signalraise SIGNAL:NAME with optional argssignal refresh $chan
timerraise a signal after a delay (ms, at least 20), the key to non-blocking worktimer 500 refresh
http.get/http.postmake a web request, deliver the result to a signal (see HTTP)http.get $url done $chan
media.pickask the user to choose a file, answer on a signal (see Files)media.pick -m image/* got $chan
media.uploadupload a picked file to a URLmedia.upload $url $mediatoken done $chan
view { … }build and mount an interactive screen (see Views)view { text "Hi" bold }
toastshow a brief on-screen noticetoast Saved
halt/returnstop the current handler (return may yield a value)halt
<namespace>.<method>call a host capability, e.g. the age.* transportage.send $chan move 5
A script can also run these app commands, in the buffer its event came from: me, join, part, cycle, topic, mode, invite, kick, knock, names, who, whois, whowas, list, notice, ctcp, nick, away, back, op, deop, voice, devoice, ban, unban, ignore and unignore. Commands that would drop or move your connection, such as quit or server, are left out on purpose; raw remains for a script that really means it. Any other unknown verb is reported in the buffer and skipped.

Value functions

Functions are written $name(arg, arg, …) and return a value you can use anywhere a value is expected.

Strings & math

FunctionReturns
$len(s)length of a string
$lower(s)/$upper(s)case conversion
$left(s,n)/$right(s,n)first/last n characters
$substr(s,start,len)substring
$replace(s,find,with)replace all occurrences
$trim(s)strip surrounding whitespace
$contains(s,sub)/$indexof(s,sub)membership test/position
$repeat(s,n)repeat a string
$calc(expr)evaluate an arithmetic expression
$mod(a,b)/$int(n)/$abs(n)modulo/floor/absolute value
$round(n,dp)/$ceil(n)/$pow(a,b)/$clamp(n,lo,hi)rounding, powers and bounds
$rand(n)/$rand(lo,hi)a random integer from 0 to n-1, or from lo to hi inclusive
$min(…)/$max(…)smallest/largest of the arguments
$pad(s,n)/$padleft(s,n)pad to width n on the right/left
$capitalize(s)/$title(s)upper-case the first letter/every word's first letter
$re_match(s,re)/$re_find(s,re)/$re_group(s,re,n)/$re_replace(s,re,with)regular expressions: test, first match, a capture group, replace all
$urlencode(s)percent-encode for a URL or form body
$json(body,path)a value from JSON text by dotted path, e.g. results.0.url
$setting(key)read a host setting (e.g. $setting(applang))

Lists & maps

FunctionReturns
$list(a,b,…)build a list
$map(k,v,k,v,…)build a map from key/value pairs
$get(coll,key)element by index, or value by key
$has(coll,key)true if the key/value is present
$keys(map)/$values(map)the keys/values of a map
$len(coll)element count
$split(s,sep)/$join(list,sep)string ↔ list
$sort(list)/$reverse(list)sorted/reversed copy
$slice(list,from,to)sub-list
$concat(a,b,…)join lists end to end
$find(list,x)/$count(list,x)index of/number of occurrences
$sum(list)numeric total
$range(lo,hi)a list of integers from lo to hi
$pick(list)/$shuffle(list)a random element/a shuffled copy
$tojson(value)serialise a list, map or scalar to JSON, ready for http.post -b

Control flow

Conditions live in parentheses and combine with && and ||. Comparisons support ==, !=, <, >, <=, >=, plus isin (substring) and iswm (wildcard match).

if ($nick == ChanServ) {
  ; ignore services
} elseif (%warned > 3 && $isme == false) {
  msg $chan settle down
} else {
  inc %warned
}

foreach %p $keys(%score) {
  echo $chan %p has $get(%score, %p) points
}

while ($len(%queue) > 0) {
  ; ... process and shrink %queue ...
}

foreach <item> <collection> walks a list or a map's keys. halt stops the whole handler; return exits the current alias (optionally with a value).

HTTP & timers

Web requests are asynchronous: you name a signal to receive the result, plus any context words you want passed along.

; http.get  [flags] <url> <signal> [context...]
; http.post [flags] <url> <body> <signal> [context...]
http.post https://libretranslate.example/translate q=$urlencode($text)&source=auto&target=en done $chan

Flags go before the URL:

FlagEffect
-t <type>set Content-Type instead of letting the body's shape decide
-h <name:value>add a request header; repeat for more than one
-b %vartake the body whole from a variable, spaces and quotes included

Flag values are read before argument expansion, so a %var in one arrives as that variable's whole value rather than the words the argument splitter would otherwise break it into. That is what makes -b the way to send JSON: the positional body is a single space-delimited word, so anything containing a space has to come through -b. A header value with spaces in it (a bearer token, say) goes the same way.

set %auth Authorization: Bearer $2
set %payload $tojson($map(text, $1-, lang, irc))
http.post -t application/json -h %auth -b %payload https://paste.example/api done $chan

Without -t, the content type is inferred from the body: one starting { or [ is sent as JSON, one shaped like key=value&key=value is sent form-urlencoded, and anything else goes as text/plain.

In the receiving handler, the response is available through these fields, and $json(body, path) pulls a value out of a JSON reply:

Field/functionValue
$httpoktrue if the request succeeded
$httpstatusHTTP status code
$httpbodythe raw response body, cut off after about 1 MB
$httplocationthe Location header, or empty. Upload endpoints answering 201 Created put the new URL here rather than in the body
$httperrorwhy the request never got a status when $httpstatus is 0: blocked by policy, unreachable, a file too large, and so on
$json(body, path)a field from a JSON body by dotted path: translatedText, detectedLanguage.language, results.0.filePath
on SIGNAL:done {
  if ($httpok == true) {
    echo $1 ↳ $json($httpbody, translatedText)   ; $1 = the buffer we passed as context
  }
  else { echo $1 *** request failed ($httpstatus) $httperror }
}
Use timer for anything repetitive or heavy. Every handler, including timer and HTTP callbacks, runs on the UI thread, one at a time, so don't loop a screen-repaint synchronously. Schedule the next step with timer <ms> <signal> instead, each tick yields, the UI stays responsive, and you avoid the "app not responding" trap.

Picking & uploading files

A script has no filesystem access and cannot name a path. The only way it sees a file is if you hand it one:

; media.pick   [-m <mime>] <signal> [context...]
; media.upload [-h <name:value>] [-p <name=value>] [-f <field>] [-r] <url> <token> <signal> [context...]
media.pick -m image/* picked $chan

media.pick does not open anything by itself. HexDroid shows a prompt naming the channel and network the script is running in, and only if you agree does the system file picker appear. The script is then handed a token for that one file, never a path, and can reach nothing else on the device. The pick's signal goes only to the script that asked, and only that script can upload with the token. Tokens last until scripts are reloaded.

FieldValue
$mediaoktrue if a file was chosen, false if you declined
$mediatokenthe opaque handle to pass to media.upload
$medianamefile name as shown to you
$mediamimeMIME type
$mediasizesize in bytes, or -1 when the provider doesn't say

media.upload sends the file to the URL without the bytes ever passing through the script. It POSTs multipart/form-data under field name file by default; -f <field> renames the field, -p <name=value> adds a text form field beside the file (repeatable, for an endpoint's own options), and -r sends the raw bytes as the request body instead, which is what a soju-style filehost wants. The body is prepared first so the request carries an exact Content-Length, which servers that read uploads by length (PHP among them) need. Files over 64 MB are refused. The reply carries the same fields an http.post does, $httplocation and $httperror included.

alias img { media.pick -m image/* picked $chan }

on SIGNAL:picked {
  if ($mediaok != true) { return }
  toast Uploading $medianame
  media.upload https://paste.example/img $mediatoken done $1
}

on SIGNAL:done {
  if ($httpok != true) { echo $1 upload failed ($httpstatus) $httperror | return }
  set -l %url $trim($httplocation)
  if ($len(%url) == 0) { set -l %url $trim($httpbody) }
  msg $1 %url
}

Redirects are not followed, so an endpoint answering with a 301 to a trailing slash or to an index.php reports that 301 rather than uploading. The error names where it wanted to send you, and that address is the one to use.

Check the body even on a 2xx: many upload endpoints answer 200 with JSON that describes a failure, so $httpok alone does not mean the file was stored.

Note there is no empty-string literal in .hex: %url == "" compares against a two-character string and is never true for a blank value. Test emptiness with $len(%url) == 0.

One pick at a time. A second request while the prompt is up is answered with $mediaok false rather than stacking dialogs, so a looping script cannot bury the app under choosers. After you decline, that script's further picks are refused without a prompt for a minute, unless you started them with a command. Redirects are not followed for an upload: a 3xx is reported instead of resending the file to a host the permission check never saw.

Interactive views

A view { … } block describes a screen using a small layout DSL, then mounts it. Rebuild and re-mount it whenever your state changes to "redraw". Buttons report taps back as signals, so a view plus a few on SIGNAL handlers is a complete little app.

Elements

ElementWhat it is
column { … }/row { … }vertical/horizontal stacks of children
stack { … }/ring { … }overlay children/arrange them in a circle
surface { … }a panel (background, padding, elevation, gradient)
text "…"a label
button "label" <actionId> [args]a tappable button that raises on SIGNAL:<actionId>
card "Ah" [red] [back]a playing card (rank+suit, optionally face-down)
image "<url>"a remote image
spacerflexible empty space

Modifiers

Any element takes trailing modifiers: bold, fill, wrap (on a row, flow children onto extra lines instead of overflowing), circle, color <hex>, bg <hex>, bgimage <url>, gradient linear:<a>:<b>, textsize <sp>, width/height/size/radius <dp>, pad/gap <dp>, weight <n>, align <where>, border <hex> [w], offsetx/offsety <dp>, and elevation <dp>. On an image, the scale mode is crop (the default), fit, or stretch.

alias counter_render {
  view {
    surface bg #13243f radius 20 pad 16 {
      column gap 12 align center {
        text Counter bold color #ffffff textsize 18
        text %n color #8fd0ff textsize 40
        row gap 10 {
          button "-1" dec weight 1
          button "+1" inc weight 1
        }
        button "Reset" reset fill
      }
    }
  }
}

on LOAD          { set %n 0 }
on SIGNAL:inc    { inc %n | counter_render }
on SIGNAL:dec    { dec %n | counter_render }
on SIGNAL:reset  { set %n 0 | counter_render }
Closing a view. When the user closes a mounted screen, the engine raises SIGNAL:view_closed. Handle it to stop any timers or loops your view was driving, so a pending tick can't re-open it.

Encrypted transport (advanced)

Scripts that need to talk to other players or peers can use the age.* capability, the same Ed25519 + X25519 machinery behind +AGE. It provides identity (age.me), randomness and hashing (age.rand, age.sha), sending over a keyed channel (age.send), local loopback for solo/practice play (age.local), and sealing a secret to one recipient (age.seal). Inbound traffic arrives as on SIGNAL:age_msg (and dealt secrets as on SIGNAL:age_deal).

The complete on-the-wire format, encoding, key derivation, sealed invites, the signed channel layer, and the 1:1 handshake and double ratchet, is published in the age-wire-format specification, so other clients can interoperate. For message-level chat encryption you do not need scripting at all, see the Encryption guide.

Worked examples

1 · A dice roller

Adds /roll (and /roll 20 for a d20).

alias roll {
  set -l %sides $1
  if ($len(%sides) == 0) { set -l %sides 6 }   ; default d6
  set -l %r $rand(1, %sides)                   ; 1 to %sides inclusive
  me rolls a %sides-sided die: %r
}

2 · Keyword highlighter

Locally flags lines that mention a watch-word, without touching the server.

on LOAD { set %kw_watch deploy }
on TEXT {
  if ($isme == false && $contains($lower($text), %kw_watch) == true) {
    echo $chan ⚠ watch-word from $nick
  }
}

3 · Auto-translate incoming lines

The pattern the bundled translate.hex uses: post each foreign line to a translation endpoint and echo the result underneath. Set your own endpoint and key at the top.

on LOAD {
  set %tr_lang $setting(applang)
  if ($len(%tr_lang) == 0) { set %tr_lang en }
  set %tr_ep https://libretranslate.example/translate   ; your endpoint
  set %tr_key                                            ; API key (blank = none)
}

on TEXT {
  if ($isme == false) {
    if ($len(%tr_key) > 0) { http.post %tr_ep q=$urlencode($text)&source=auto&target=%tr_lang&api_key=%tr_key tr $chan $text }
    else { http.post %tr_ep q=$urlencode($text)&source=auto&target=%tr_lang tr $chan $text }
  }
}

on SIGNAL:tr {
  if ($httpok == true) {
    set -l %out $json($httpbody, translatedText)
    if (%out != $2-) { echo $1 ↳ %out }   ; $1 = buffer, $2- = original text
  }
  else { echo $1 *** translate failed ($httpstatus) $httperror }
}

4 · Filter WHOIS replies

The pattern the bundled whoisfilter.hex uses: drop the numerics you never read. Editing one list is easier than remembering which code is which, so keep the comment next to it. The $whois test keeps the filter to WHOIS output, so a 301 away reply to a message you sent still shows.

on LOAD {
  set %wf_hide $list(310, 337, 339, 344, 350, 378, 379)
}

on NUMERIC {
  if ($whois != true) { return }
  if ($has(%wf_hide, $code) == true) { halt }
}

5 · Upload an image and paste the URL

The shape of the bundled imgpaste.hex. /img asks you for a file, uploads it, and sends the returned URL to the channel. It targets an endpoint that reads the file from an images field, strips photo metadata when strip_exif is set, and answers 200 with JSON either way, so the body decides success.

on LOAD {
  set %img_ep https://paste.example/img/index.php   ; exact URL, file name included
  set %img_base https://paste.example                ; the returned path hangs off this
}

alias img { media.pick -m image/* img_picked $chan }

on SIGNAL:img_picked {
  if ($mediaok != true) { return }
  toast Uploading $medianame
  media.upload -f images -p strip_exif=1 %img_ep $mediatoken img_done $1
}

on SIGNAL:img_done {
  if ($httpok != true) { echo $1 *** upload failed ($httpstatus) $httperror | return }
  set -l %err $json($httpbody, results.0.error)
  if ($len(%err) > 0) { echo $1 *** upload rejected: %err | return }
  set -l %path $json($httpbody, results.0.filePath)
  if ($len(%path) == 0) { echo $1 *** no path in the reply | return }
  msg $1 %img_base%path
}

6 · A scoreboard with a view

Tracks points per nick (/point nick) and shows a live board.

on LOAD { set %sb_score $map() }

alias point {
  if ($has(%sb_score, $1) == false) { setat %sb_score $1 0 }
  setat %sb_score $1 $calc($get(%sb_score, $1) + 1)
  board_render
}

alias board_render {
  view {
    surface bg #161b22 radius 16 pad 16 {
      column gap 8 {
        text Scoreboard bold textsize 18 color #ffffff
        foreach %p $sort($keys(%sb_score)) {
          row gap 8 { text %p weight 1 | text $get(%sb_score, %p) bold color #58a6ff }
        }
        button "Clear" clear fill
      }
    }
  }
}

on SIGNAL:clear { set %sb_score $map() | board_render }

Installing & editing

  • Enable/disable, bundled scripts ship disabled; flip the toggle to opt in. A disabled script never runs and never registers its commands or launchers.
  • Import/Paste, add your own .hex from a file or the clipboard.
  • Edit, opens a full-screen editor with line numbers. Save & reload writes it back and reloads immediately, so changes (like an endpoint or API key) take effect at once.
  • Revert, bundled scripts can be restored to their shipped default if an edit goes wrong.
  • Remove, delete a script you added.
To drop in an API key or point a script at your own server, open the script in the editor, change the marked set %… line near the top, and Save & reload. There is no separate settings field, the script is the configuration.

From here, browse the command reference for everything a script can drive, or the encryption guide for message-level E2EE that needs no scripting at all.